<?php
namespace DRY\Authentication;

/**
 * Authentication Credentials storage using one file per user (and possibly a filename hash system)
 * Usually suited for projects with one or few users, and projects which do not use databases at all
 * Data storage format can be PHP's Serialize, JSON, XML, PHP's Var_Export, YAML, MsgPack
 *
 * @author Wavetrex <wavetrex@gmail.com>
 * @link http://www.donrey.net/Documentation/Structure/Modules/authentication.html#files
 * @license [GPL v3] http://www.gnu.org/licenses/gpl-3.0.html
 * @version 1.0.dev
 * @package DonReY
 * @subpackage Module.Authentication
 *
 * @TODO Support sending the salt from outside and already encrypted password (JavaScript side pass-hashing)
 */
class Storage_Files implements Storage
{
	// Exception/Error codes
	const
		EXC_PATH_NOTSET = 1,
		EXC_PATH_MISSING = 2,
		EXC_FORMAT_NOT_SUPPORTED = 3
		;

	protected $CFG;

	/**
	 * Path where user's credentials are being stored - Loaded from config
	 * @var string
	 */
	private $storage_path;

	/**
	 * Credentials data
	 * @var array
	 */
	protected $cdata = array();

	public function __construct($CFG)
	{
		$this-> CFG = $CFG;
		$this-> set_Password_Encryption($CFG-> encryption?:1); // set type of password encryption

		// setup path where credentials will be stored
		if(isset($this-> CFG-> path)) {
			$rel_path = constant(isset($this-> CFG-> path['rel'])?$this-> CFG-> path['rel']:'APP_PATH');
			$this->storage_path = realpath($rel_path.$this-> CFG-> path);
			if(empty($this->storage_path))
				throw new \DRY\Water('authentication.files', self::EXC_PATH_MISSING);
		} else
			throw new \DRY\Water('authentication.files', self::EXC_PATH_NOTSET);

		// === Supported formats ===
		// serialize: http://www.php.net/manual/en/function.serialize.php
		// json: http://www.json.org/
		// xml: http://en.wikipedia.org/wiki/Xml
		// php: http://www.php.net/manual/en/function.var-export.php
		// yaml: http://en.wikipedia.org/wiki/Yaml (external library, Spyc)
		// msgpack: http://en.wikipedia.org/wiki/MessagePack (external library, MsgPack_Coder)
		if(!in_array($this-> CFG-> format, array("serialize","json","xml","php","yaml","msgpack")))
			throw new \DRY\Water('authentication.files', self::EXC_FORMAT_NOT_SUPPORTED, array('format'=>(string)$this-> CFG-> format));
	}

	/**
	 * What function to use when transforming the password (prior to registering, checking or updating)
	 * @var {callback}
	 */
	private $password_transform_function;

	/**
	 * Returns the incoming password as-is
	 * @param string $password
	 */
	protected function password_No_Transform($password)
	{
		return $password;
	}

	/**
	 * Hashes the incoming password before sending it to the storage
	 * @param string $password
	 */
	protected function password_Hash($password, $account)
	{
		$algo = $this-> CFG-> encryption['algo']?:'sha256';
		if(!isset($this-> cdata['salt']))
			$this-> cdata['salt'] = base64_encode(pack('f',mt_rand() / mt_getrandmax()));
		return hash($algo, $this-> cdata['salt']. $account. $password, $this-> CFG-> format == 'serialize' || $this-> CFG-> format == 'msgpack'); // merges a float number between 0 and 1 (packed, base64 encoded) with the account name and the password, and creates a hash out of it
	}

	/**
	 * @see DRY\Authentication\Storage::set_Password_Encryption()
	 */
	public function set_Password_Encryption($mixed=true)
	{
		if(is_callable($mixed))
			$this-> password_transform_function = $mixed;
		elseif((bool)$mixed) {
			$this-> password_transform_function = array($this, 'password_Hash');
		} else
			$this-> password_transform_function = array($this, 'password_No_Transform');
	}

	/**
	 * @see DRY\Authentication\Storage::create_Credentials()
	 * @return bool True = success, False = fail
	 */
	public function create_Credentials($account, $password, $status=0, $extra=array())
	{
		// create the credentials data that will be stored
		if(!is_array($extra)) $extra = array();

		// check if the file already exists
		$filepath = $this->storage_path.'/'.$account.'.'.$this-> CFG-> format;

		if(file_exists($filepath))
			return false; // error

		$credentials = array(
			'username' => $account,
			'security' => call_user_func($this-> password_transform_function, $password, $account, $this-> CFG),
			'status' => $status
		);
		$this-> cdata = array_merge($this-> cdata, $extra, $credentials);

		$this-> save_cData($filepath);
		return true;
	}

	/**
	 * Encodes and saves $this->_cdata in a file
	 * @param string $filepath
	 */
	private function save_cData($filepath)
	{
		// encode data in a string and write file
		switch($this-> CFG-> format) {
			case 'serialize':
				file_put_contents($filepath, serialize($this-> cdata));
				break;
			case 'json':
				file_put_contents($filepath, json_encode($this-> cdata));
				break;
			case 'xml':
				$output = new \SimpleXMLElement("<?xml version='1.0' encoding='UTF-8' ?><account />");
				foreach($this-> cdata as $key=>$var) {
					$child = $output-> addChild($key, $var);
					if(gettype($var)!="string")
						$child['dt'] = gettype($var);
				}
				$output-> asXML($filepath);
				break;
			case 'php':
				file_put_contents($filepath, "<?php\n\$this-> cdata = ".var_export($this-> cdata, true).';');
				break;
			case 'yaml':
				// core should autoload Spyc if it was configured, or an exception will occur
				file_put_contents($filepath, \Spyc::YAMLDump($this-> cdata));
				break;
			case 'msgpack':
				// core should autoload MsgPack_Coder if it was configured, or an exception will occur
				file_put_contents($filepath, \MsgPack_Coder::encode($this-> cdata));
				break;
		}
	}

	private $passed_validation = false;

	/**
	 * @see DRY\Authentication\Storage::check_Credentials()
	 */
	public function check_Credentials($account, $password)
	{
		$this-> passed_validation = false;
		// check if the account exists, if not, return false
		$filepath = $this->storage_path.'/'.$account.'.'.$this-> CFG-> format;
		if(!file_exists($filepath))
			return null; // user not found

		$this-> read_cData($filepath);

		// compare security string
		if($this-> cdata['security'] == call_user_func($this-> password_transform_function, $password, $account, $this-> CFG)) {
			$this-> passed_validation = true;
			return $this-> cdata['status'];
		}
		return false;
	}

	/**
	 * Loads a file and decodes into $this->_cdata
	 * @param string $filepath
	 */
	private function read_cData($filepath)
	{
		// decode data from the file
		switch($this-> CFG-> format) {
			case 'serialize':
				$this-> cdata = unserialize(file_get_contents($filepath));
				break;
			case 'json':
				$this-> cdata = json_decode(file_get_contents($filepath), true);
				break;
			case 'xml':
				$data_xml = new \SimpleXMLElement(file_get_contents($filepath));
				$this-> cdata = array();
				foreach($data_xml-> children() as $name=>$element) {
					$name = (string)$name;
					$this->cdata[$name] = $element;
					if($element['dt'])
						settype($this-> cdata[$name], $element['dt']);
					else
						settype($this-> cdata[$name], "string");
				}
				break;
			case 'php':
				include $filepath;
				break;
			case 'yaml':
				// core should autoload Spyc if it was configured, or an exception will occur
				$this-> cdata = \Spyc::YAMLLoad($filepath);
				break;
			case 'msgpack':
				$msg = file_get_contents($filepath);
				// core should autoload MsgPack_Coder if it was configured, or an exception will occur
				$this-> cdata = \MsgPack_Coder::decode($msg);
				break;
		}
	}

	/**
	 * @see DRY\Authentication\Storage::update_Credentials()
	 */
	public function update_Credentials($account=null, $status=null, $password=null)
	{
		if(is_null($account) && !$this-> passed_validation)
			return false;

		if(!is_null($account)) {
			$temp_data = $this-> cdata;
			$filepath = $this->storage_path.'/'.$account.'.'.$this-> CFG-> format;
			if(!file_exists($filepath))
				return false; // error
			$this-> read_cData($filepath);
		} else {
			$filepath = $this->storage_path.'/'.$this-> cdata['username'].'.'.$this-> CFG-> format;
		}

		// change status
		if(!is_null($status))
			$this-> cdata['status'] = $status;
		// and or change password ( and regenerate salt )
		if(!is_null($password)) {
			unset($this-> cdata['salt']);
			$this-> cdata['security'] = call_user_func($this-> password_transform_function, $password, $this-> cdata['username'], $this-> CFG);
		}

		if(!file_exists($filepath))
			return false; // error
		$this-> save_cData($filepath);

		if(!is_null($account)) {
			$this-> cdata = $temp_data;
		}
		return true;
	}

	/**
	 * @see DRY\Authentication\Storage::delete_Credentials()
	 */
	public function delete_Credentials($account=null)
	{
		if(is_null($account) && !$this-> passed_validation)
			return false;

		if(is_null($account)) {
			$account = $this-> cdata['username'];
			$this-> passed_validation = false;
			$this-> cdata = array();
		}

		$filepath = $this->storage_path.'/'.$account.'.'.$this-> CFG-> format;
		if(!file_exists($filepath))
			return false; // error

		unlink($filepath);
	}

	/**
	 * @see DRY\Authentication\Storage::read_Extra()
	 */
	public function read_Extra($account=null)
	{
		if(is_null($account) && !$this-> passed_validation)
			return null;

		if(!is_null($account)) {
			$temp_data = $this-> cdata;
			$filepath = $this->storage_path.'/'.$account.'.'.$this-> CFG-> format;
			if(!file_exists($filepath))
				return null; // error
			$this-> read_cData($filepath);
		}
		$data = $this-> cdata;
		unset($data['security'], $data['salt']);
		if(!is_null($account)) {
			$this-> cdata = $temp_data;
		}
		return $data;
	}

	/**
	 * @see DRY\Authentication\Storage::update_Extra()
	 */
	public function update_Extra($extra=null, $account=null)
	{
		if(is_null($account) && !$this-> passed_validation)
			return false;

		unset($extra['security'], $extra['salt'], $extra['status']); // disable changing credentials with this method

		if(!is_null($account)) {
			$temp_data = $this-> cdata;
			$filepath = $this->storage_path.'/'.$account.'.'.$this-> CFG-> format;
			if(!file_exists($filepath))
				return false; // error
			$this-> read_cData($filepath);
		} else {
			$filepath = $this->storage_path.'/'.$this-> cdata['username'].'.'.$this-> CFG-> format;
		}
		$this-> cdata = array_merge($this-> cdata, $extra);

		if(!file_exists($filepath))
			return false; // error
		$this-> save_cData($filepath);

		if(!is_null($account)) {
			$this-> cdata = $temp_data;
		}
		return true;
	}
}
/* Example config:
	<authentication alias='usr' depends='prs'>
		<!-- storage provider type and configuration for it -->
		<storage type='files'>
			<encryption algo='sha256'>1</encryption>
			<!-- Supports: serialize, php (var_export), xml, json, yaml, msgpack (serialize and msgpack store the "security" field in binary, others in hex16 -->
			<format>serialize</format>
			<path rel='APP_PATH'>/data/credentials</path>
		</storage>
		<!-- configuration for session storing (after successful authentication). This module needs the "persistence" module to store it's session data -->
		<session module='prs'>
			<!-- it's not mandatory to use a session container, maybe some project could use a cookie container for permanent sessions (until deauth) -->
			<container type='session'>auth_user</container>
			<!-- kill the authenticated session after 10 minutes, even if the php session itself hasn't expired -->
			<timeout autoreset='true'>600</timeout><!-- Autoreset at each init, so the real timeout happens only after 600 seconds of inactivity, not from auth time -->
		</session>
	</authentication>
 */